通常一個活動,最後登場的都是主角吧?理應如此,筆者記得有一年的金馬獎頒獎典禮,主辦單位不知道哪根筋打到,突然就把「最佳男主角」跟「最佳女主角」的獎項,挪到典禮中半段頒發,可想而知,頒完後,大家紛紛離場,後面的獎項場面變得異常冷清,當然轉播的收視率也就慘不忍睹了。
這故事告訴我們,主角還是要壓軸登場才好。
於是,今天來聊 Clean Architecture 中,最核心的 Entity,以及負責存取資料 的 Repository。
Entity 負責系統最核心的邏輯,這大家都知道,但到底什麼是最核心的邏輯?卻不是那麼容易想像。於是,有人就這麼做:「把資料庫的 Table 拿來,一對一地翻成程式物件,並稱它們為 Entity」。
咦,我都是這麼做的呀,這樣不對嗎?
嗯,也不能說完全不對,但就是哪裡怪怪的,應該說,有可能到最後做出來的東西不會差很多,但方向不對了,或者更精確地說:誤把資料庫錯當成系統的核心了。資料庫的工作是儲存資料,對系統來說,就是個放東西的地方。Uncle Bob 認為這些東西雖重要,但最多就是個「重要的細節」,因為 Oracle 靠資料庫賺錢、MySQL 靠資料庫賺,但你不是,你靠你的 Solution 賺錢。所以 DB Table 決定 Entity 樣貌是不合理的,再怎樣也應該讓 Entity 決定 DB Table 樣貌才對。
好吧,那到底該由誰來決定 Entity 樣貌?
筆者認為,應該由「Domain Model」來決定比較合適。Domain Model 就是你對 Problem Domain 的描繪,而 Solution 拿來解決 Problem Domain 的問題,且 Entity 是 Solution 的一部份,因此由 Domain Model 來決定 Entity 的樣貌比較合理。
還是有點抽象耶!能再具體一點嗎?
可以唷!「去問 Use Case 吧!」在提出 Solution 來解決真實世界的問題時,我們會與 Stakeholders (也可能是PO、PM 等)討論一些重要「場景」,譬如申請獎學金、選課、期中退選等等,這些場景在 Clean Architecture 中,被定義為 Use Case 的工作。於是,在 Use Case 實現的過程中,我們會對一些關鍵的參與角色有一些期待。可能期待它們提供一些資料,可能期待它們做一些事情。這些「期待」,便定義了 Entity 該做的事。隨著被定義的 Use Case 越來越多,Entity 們的樣貌也就能愈趨完整。
Entity 有行為?我有沒有聽錯?
你沒有聽錯。在物件導向的世界裡,物件的「封裝」,就是期待物件之件盡量少拿對方資料,而多叫對方做事。這樣,物件自己身上要以什麼方式,存放什麼資料,呼叫方就不用管,爾後當資料要換個方式存放,甚或是要再轉包出去給別人存放時,只要對外行為不變,呼叫方也就完全不會受影響,也就自然達到解耦合了。
在前一篇中,我們發現了申請獎學金的 Service 有一個壞味道:Feature Envy。還記得我們說了為什麼暫時不處理嗎?就是為了留到現在,我們來看看 Entity 怎麼為了 Service 好用而「演化」出來。先看原程式:
private void checkDeadline(Scholarship scholarship) throws ClientSideErrorException {
LocalDate deadline = scholarship.getDeadline();
LocalDate now = LocalDate.now();
if (now.isAfter(deadline)) {
throw new ClientSideErrorException("application over time", 374);
}
}
private void checkProgramIsPhD(Student student) throws ClientSideErrorException {
if (!student.getProgram().equals("PhD")) {
throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
}
}
上面對時間的檢查 checkDeadline 中,deadline 是放在 Scholarship 這個 Entity 中的,而檢查身份的 checkProgramIsPhD 也不是 Service 的工作,我們也認為這是因為「這個 Scholarship」是 PhD 專用的,應是屬於 Scholarship 自已定義的邏輯,而比較適合放在 Scholarship 中(儘管後者還沒有明顯的 Feature Envy)。
因此,我們利用 Move Method 的手法,把這兩個工作,搬到 Scholarship 中,只留下流程,原方法就成了:
private void checkDeadline(Scholarship scholarship) throws ClientSideErrorException {
if (scholarship.checkDeadline()) {
throw new ClientSideErrorException("application over time", 374);
}
}
private void checkProgramIsPhD(Scholarship scholarship, Student student) throws ClientSideErrorException {
if (!scholarship.checkQualification(student)) {
throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
}
}
搬過去時,我們也順便把「檢查資格」的方法,改名為 checkQualification,這下「檢查學生是否符合此獎學金申請資格」的工作,就落到 Scholarship 身上,成為 Entity 要提供給 Use Case 使用的「核心邏輯」了。
我們來看看新介面,現在這個檢查介面暗示我們「獎學金會依自己的設定,去檢查學生的資格」,聽起來蠻合理的,這證明這個重構是有助於理解的,我們可以放心了。
至此,我們把一般 Clean Architecture 初學者最常搞混的 Use Case 與 Entity 權責理清楚了,單就「檢查資格」這件事來看,這兩者的工作分別是:
爾後,隨著系統開發的進行,Use Case 一直增加,Entity 的樣貌自然就能慢慢堆疊出來。至於 Entity 身上該怎麼存放資料,怎麼處理「大學生」與「碩士生」的事情…這個嘛,就又回到我們更早之前講的,Solid、多型、注入、設計模式…等的基本功,不需要 Clean Architecture 出馬了。
注意:你發現了嗎?這時,我們都還不需要決定 DB 的樣貌,甚至還不用決定是否需要一個 DB 唷!別忘了,「延遲決定」也是敏捷開發重要的手段之一。
至於在系統中,擔任「檔案管理員」的 Repository,在 Controller、Service、Entity 都完成後,我們就知道該怎麼設計它了。因為這時「使用者們」都已經做好了嘛!照著介面刻,應是不會出什麼大亂子。
Repository 在系統中,大多時間在處理「黑手」的工作,如果資料放在 DB,它就去操作 DB,如果資料放在第三方 API,它就去操作第三方 API,以此類推,它就是一個「為 Service 服務,代替 Service 處理 I/O 細節」的無名英雄。
無名英雄示意圖,截自 YouTube
有了 Repository,Service 就不用管 I/O 的操作細節,今天就算把 Database 從 MySQL 換成 Oracle,或是加個 Redis 當 Cache,Service 一概不用知道,如此,Service 就可以專心當它的「流程管理者」,權責就分開了。
Repository 身為「檔案管理員」,根據 Uncle Bob 的規劃,應該放在 Clean Architecture 的 Interface Adapter 層,直接與框架和 I/O 接觸,但 Service 身處較內層的 Use Case 層,想要操作 Repository,又不能違反 Clean Architecture 的「依賴原則 - 內層不依賴於外層」,這該怎麼辦呢?
其實答案也在先前講過了,要請 DIP:Dependency Inversion Principle 來幫忙。在這個場景下,Service 是需求方,它要訂好一個介面,讓身在外層的 Repository 去實作,此外層依賴於內層是沒有問題的,而這個介面本身則與 Service 一起放在 Use Case 層,這樣一來同層依賴也沒有問題,事情就解決了。
讀者可以參考一下 GitHub Repository 中,ApplyScholarshipService 所依賴的三個 Interface,會比較具體一些。
我們討論了 Clean Arcitecture 的分層原則中,位於最核心的 Entity,以及存取 Entity 的無名英雄 Repository,並再一次複習了如何同時遵守 Uncle Bob 提出的另外兩個原則:依賴原則與跨層原則。至此,我們算是對系統的一個 Command,從頭到尾完整地走了一遍。筆者的經驗,如果每個 Command 都能像這樣按部就班地安排,相信架構要亂也亂不到哪裡去。
當然,會有點麻煩。
麻煩是麻煩了點,但是也沒辦法,為了不要讓不乾淨的程式碼使我們「越來越慢」,我們只能時時注意,養成好習慣。才能防範於末然。
謎之聲:「習慣了,也就好了。」
ithelp2021